#! /usr/bin/env python
import os, re, stat, sys, time, threading, unittest
from optparse import OptionParser
from token import *
import parser, types, symbol
import cgi

try:
    from thumb_conf import *
except ImportError:
    thumb_pattern       = '\\/.*_test\\.py$'
    thumb_verbosity     = 1
    thumb_ignore_files   = []
    thumb_coverage_level= 100
    def pre_test_run(): pass
    def post_test_run(): pass

current_path = os.path.realpath(os.curdir).lower()
test_file_pattern = re.compile(thumb_pattern)

def get_all_files(pattern=None, ignored_folders=[]):
    files = []
    for dp, dn, fn in os.walk(current_path):
        if dp in ignored_folders: continue
        for file in fn:
            filename = os.path.join(dp, file)
            if pattern:
                if pattern.search(filename):
                    files.append(filename)
            else:
                if filename.endswith('.py'):
                    files.append(filename)
    return files


class LineGrabber:
    """Get executable lineno from 'src':    `LineGrabber().lines` """
    def __init__(self, src):
        self.original_lines = open(src).readlines()
        self.lines = set()
        self.ast = parser.suite('\n'.join([l.strip('\r\n') for l in self.original_lines]))
        self.tree = parser.ast2tuple(self.ast, True)
        self.find_terminal_nodes(self.tree)

    def find_terminal_nodes(self, tup):
        (sym, rest) = tup[0], tup[1:]

        line_nos = set()
        if type(rest[0]) == types.TupleType:  ### node

            for x in rest:
                token_line_no = self.find_terminal_nodes(x)
                if token_line_no is not None:
                    line_nos.add(token_line_no)

            if symbol.sym_name[sym] in ('stmt', 'suite', 'lambdef',
                                        'except_clause') and line_nos:
                # store the line number that this statement started at
                self.lines.add(min(line_nos))
            elif symbol.sym_name[sym] in ('if_stmt',):
                # add all lines under this
                self.lines.update(line_nos)
            elif symbol.sym_name[sym] in ('global_stmt',): # IGNORE
                return
            else:
                if line_nos:
                    return min(line_nos)
        else:                               ### leaf
            if sym not in (NEWLINE, STRING, INDENT, DEDENT, COLON) and \
               tup[1] != 'else':
                return tup[2]
            return None

class CodeTracer:
    def __normalize_path(self, path):
        return os.path.normpath(path).lower()

    def __init__(self):
        self.coverage = {}
        self.ignore_files = [self.__normalize_path(os.path.join(current_path, filename)) for filename in thumb_ignore_files + ['thumb.py', 'thumb_conf.py']]
        self.ignore_folders = [self.__normalize_path(os.path.join(current_path, dirname)) for dirname in thumb_ignore_folders]
        self.source_files = []

        for filename in get_all_files():
            ignore_file = False
            filename = self.__normalize_path(filename)

            if filename in self.ignore_files: continue

            for folder in self.ignore_folders:
                if filename.startswith(folder): 
                    ignore_file = True
                    break
            if ignore_file: continue

            if test_file_pattern.search(filename): continue

            self.source_files.append(filename)
            self.coverage[filename] = set()

    def start(self):
        sys.settrace(self.trace)
        if hasattr(threading, 'settrace'): threading.settrace(self.trace)

    def stop(self):
        sys.settrace(None)
        if hasattr(threading, 'settrace'): threading.settrace(None)
        return self.coverage

    def trace(self, f, e, a):
        if e is 'call':
            filename = self.__normalize_path(f.f_code.co_filename)
            if filename not in self.source_files: return None
            else: return self.collect

    def collect(self, f, e, a):
        if e is 'line':
            filename = self.__normalize_path(f.f_code.co_filename)
            lineno   = f.f_lineno
            self.coverage[filename].add(lineno)
            return self.collect

class CoverageReporter:
    def __init__(self, coverage, report="coverage.html"):
        self.root_path = current_path
        self.coverage = coverage

        self.output = open(report, 'w')

    def generate_report(self):
        total_code_lines = 0
        total_hit_lines  = 0

        self.output.write("""<html>
    <head>
        <style type="text/css">
            .hit { color:green; }
            .ignore { color:gray; }
            .miss { color:red; }
            .file { font-size: 110%; margin-bottom:5px; }
            .score { position:absolute; left: 600px; }
            .code { font-size:90%; margin-top: 25px;  }
            pre { margin-top:-15px; }
        </style>
        <script langauge="JavaScript" type="text/javascript">
            function show(id) {
                var node = document.getElementById(id)
                if(node.style.display=="none") {
                    node.style.display="block";
                } else {
                    node.style.display="none";
                }
            }
        </script>
    </head>
<body>\n""")

        filenames = self.coverage.keys()
        filenames.sort()
        for filename in filenames:
            hitlines = self.coverage[filename]
            codelines = LineGrabber(filename).lines
            codelines.update(hitlines)
            hitlines = [line for line in hitlines if line in codelines]

            if len(codelines)==0: percentage = 100
            else: percentage = min(100*len(hitlines)/len(codelines), 100)
            self.generate_report_for_file(filename, percentage, hitlines, codelines)

            total_hit_lines     += len(hitlines)
            total_code_lines    += len(codelines)

        self.output.write("""
</body></html>\n""")
        return total_hit_lines, total_code_lines, 100*total_hit_lines/total_code_lines

    def _get_unique_name_for_src(self, src):
        return src.replace('/', '_').replace('.', '_')

    def _percentage_style(self, percentage):
        if percentage>=thumb_coverage_level: return "hit"
        else: return "miss"

    def _line_style(self, lineno, codelines, hitlines):
        if lineno in hitlines: return "hit"
        elif lineno in codelines: return "miss"
        else: return "ignore"
        
    def generate_report_for_file(self, src, percentage, hitlines, codelines):
        relative_name = src[len(self.root_path):]
        unique_name = self._get_unique_name_for_src(relative_name)

        self.output.write("""
    <div class="file">
        <span class="file-summary">
            <span class="filename"><a href="#" onclick="show('%s'); return false;">%s</a></span>
            <span class="score %s">%d%%</span>
        </span>
        <div id="%s" class="code" style="display:none">\n""" % (unique_name, relative_name, self._percentage_style(percentage), percentage, unique_name))

        fp = open(src, 'r')
        for i, line in enumerate(fp):
            lineno = i+1
            self.output.write('<pre class="%s">%4d. %s</pre>\n'%(self._line_style(lineno, codelines, hitlines), lineno, cgi.escape(line.strip('\r\n'))))
        fp.close()

        self.output.write("""
        </div>
    </div>\n""")

def get_last_modified_test_file():
    last_modified_filename  = ''
    last_modified_time      = 0.0
    for filename in get_all_files(test_file_pattern):
        if os.path.getmtime(filename)>last_modified_time:
            last_modified_time      = os.path.getmtime(filename)
            last_modified_filename  = filename
    return last_modified_filename

def get_last_modified_time():
    last_modified_time      = 0.0
    for filename in get_all_files():
        if os.path.getmtime(filename)>last_modified_time:
            last_modified_time      = os.path.getmtime(filename)
    return last_modified_time

def run_daemon():
    """
    Check the checksum every second to see whether anything has changed and send a system 
    command to launch 'last_modified' test
    """
    last_modified_time = 0.0
    while (True):
        if get_last_modified_time() > last_modified_time:
            last_modified_time = get_last_modified_time()
            os.system ('python thumb.py -l')
        time.sleep(1)

def run_test_suite(test_files):
    def _filename_to_module_name(filename):
        """
        Parse the module name from the given filename
        >>> os.path.curdir
        '/home/alex/projects/hobby'
        >>> _filename_to_module_name('/home/alex/projects/hobby/unit/encrypt_test.py')
        'unit.encrypt_test'
        """
        return filename[len(current_path)+1:-3].replace('/', '.').replace('\\', '.')

    def _parse_package_name(module_name):
        """
        Return the package name for the given module name
        >>> _parse_package_name('unit.models_test')
        'unit'
        """
        return module_name.split('.')[0]

    suite = unittest.TestSuite()
    module_names = map(_filename_to_module_name, test_files)
    for module_name in module_names: 
        module = __import__(module_name, globals(), locals(), [_parse_package_name(module_name)])
        suite.addTest(unittest.defaultTestLoader.loadTestsFromModule(module))
    result = unittest.TextTestRunner(verbosity=thumb_verbosity).run(suite)
    return result


def parse_command_line_options():
    usage = "usage: thumb.py [options]"
    op = OptionParser(usage=usage)
    op.add_option('-d', '--daemon', help='Stay in daemon mode', action='store_true')
    op.add_option('-c', '--coverage', help='Generate code coverage reporting', action='store_true')
    op.add_option('-f', '--full', help='Execute all test cases as full regression suite', action='store_true')
    op.add_option('-l', '--last_modified', help='Execute only test cases found in last modifed test file', action='store_true')
    options, args = op.parse_args(sys.argv[1:])
    return options, args

def run():
    return result

if __name__=="__main__":
    options, args = parse_command_line_options()
    
    if options.daemon:
        run_daemon()

    if options.coverage:
        tracer = CodeTracer()
        tracer.start()
        
    pre_test_run()
    if options.full:
        result = run_test_suite(get_all_files(test_file_pattern))
    else:
        result = run_test_suite([get_last_modified_test_file()])
    post_test_run()

    if options.coverage:
        print "generating code coverage analysis report ... ..."
        coverage = tracer.stop()
        report = CoverageReporter(coverage, 'coverage.html')
        total_hit, total_code, coverage_percentage = report.generate_report()
        print "%d%% ( %d/%d )   open 'coverage.html' for details." % (coverage_percentage, total_hit, total_code)

    print "%s failures, %s errors" % (len(result.failures), len(result.errors))
